Skip to content

bun test: scan each directory once, however many symlinks lead to it - #44487

Draft
robobun wants to merge 4 commits into
robobun/730a1a7f/test-scanner-cached-dirsfrom
robobun/d068ecf6/test-scanner-symlinks
Draft

robobun wants to merge 4 commits into
robobun/730a1a7f/test-scanner-cached-dirsfrom
robobun/d068ecf6/test-scanner-symlinks

Conversation

@robobun

@robobun robobun commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun test never exits when two directory symlinks lead back up the tree (ln -s . x && ln -s . y). Memory grows without a bound: 363 MB after 6 s.
  • Scanner::next (src/runtime/cli/test/Scanner.rs) queues a symlinked directory like a real one, so the walk lists a directory once per path to it.

Fix

  • The walk lists each directory once. It skips a link to a directory it reaches without the link, and lists any other linked directory under its real path.
  • Entry::link (src/resolver/fs.rs) returns the kind, a link bit, and the real path RealFS::kind already reads.
  • Behaviour change: a filter that names only a link path (bun test latest/, latest -> packages/v2) matches nothing. bun test ./latest works.
  • Verified: 25 directory links tests in test/cli/test/bun-test.test.ts. Bun 1.4.3 fails 17.

Background

Downsides

  • A filter or file ignore pattern on a link path misses. A maintainer must accept this, or I add a filter pass.
  • No links: the same syscalls and allocations, 7 more instructions per directory entry (95 to 102). Release binary: +7,424 bytes.
  • Not fixed: Bun.FileSystemRouter has the same walk (src/router/lib.rs). Two aliasing path roots still record a file twice (bun test: list a test file once when two path arguments select it #43361).
Notes

Question for a maintainer. The walk records a directory that only a link leads to under its real path. A substring filter or a file ignore pattern that names only the link path stops matching. Is that acceptable? If not, a filter pass that runs after the walk, only on trees with links, can keep both names (about 55 more lines). The other way, one listing per link path, brings back the growth this PR removes.

Stacking. The base branch is #42921 (robobun/730a1a7f/test-scanner-cached-dirs). An earlier version of this PR had its own replay of a cached listing for a followed directory (a link to a parent of the cwd needs it). #42921 does that in the resolver for every directory, so the copy is gone. If #42921 does not merge, this PR needs that replay back.

The rule, in the directory arm of Scanner::next, after the existing prunes.

  1. An entry that is not a link, in a directory the walk did not enter through a link: unchanged.
  2. A link with real path P. If P is the place of the link itself (a Windows reparse point that is not an alias), it is a plain child. If P is the scan root, or is below the root with no pruned name on the way, the walk skips it: the walk lists P by itself. If the walk already followed a link to P, it skips it. Otherwise it records P and lists P once, by absolute real path.
  3. A real child of a directory that the walk entered through a link: the same tests, with P = parent + name.
  4. A path that does not fit a path buffer: not owned, so the base behaviour.

Cost, measured. Release builds of the base (#42921 at ca43827) and of this PR (1ec4bee), built in the same directory with the same toolchain, linux x64. Counts come from gdb: catch syscall with ignore counts, breakpoints on the mimalloc entry points, and stepi through one call.

  • Link-free walk: openat/getdents64/close/stat/readlink per directory 1/2/1/0/0 (base 1/2/1/0/0). The whole-process counts are equal on trees of 51 and 251 directories.
  • Link-free walk: 3.33 allocator calls per directory (base 3.33). The whole-process totals are equal: 4,246 and 4,911.
  • Scanner::next, directory entry: 102 instructions (base 95). The 7 are the test of the link bit and the test of "this directory was entered through a link".
  • Entry 144 B, EntryCache 24 B, ScanEntry 56 B (all unchanged). Scanner +72 B, one per process.
  • Listings = distinct directories on 8 of 8 link trees (a breakpoint on the scanner's directory read against an independent count of reachable real directories). /usr/lib/llvm-23, an installed Debian package: 443 listings for 443 directories. The base never ends there, because build/Release -> .. and build/Debug+Asserts -> .. both lead to the parent.
  • A link to a directory the walk covers: 4 fewer syscalls than the base (no open, 2 getdents64, close). The stat of the link is the same 5 syscalls. The real path of the root costs 3 syscalls, once per scan, and only when the scan meets a directory link.
  • The recorded set is identical across 12 of 12 combinations of creation order (link first, link last), filesystem (ext4, tmpfs) and tree (alias, two aliases, two links to one outside directory). Each difference from the base is an alias path of a file the base also runs.
  • Release binary: +7,424 bytes of text (size: 105,075,871 to 105,083,295). Data and bss are equal. The file grows by 8,192 bytes.

Listings on the base and on this PR.

Tree Directories Base This PR
two packages that link to each other 6 166 6
two links to each of 8 directories in a row 9 511 9
the same with 10 11 2,047 11
the same with 12 13 8,191 13
100 links to one directory 2 102 2
6 directories, each with a link to every other 7 does not end in a useful time 7
x -> . and y -> . 1 never ends 1

Results: Bun 1.4.3 (367d939) against this branch. Each cell is tests / files from the Ran line.

Tree 1.4.3 This branch
x -> . and y -> . never exits (3799 files under ulimit -n 64) 1 / 1
examples/one/lib -> ../.. and examples/two/lib -> ../.. never exits 1 / 1
a/loop -> .. and alias -> a never exits 1 / 1
a/loop -> .. 1 / 41 1 / 1
sub/loop -> .. and sub/deeper/self -> . 2 / 902 2 / 2
two packages that link to each other 2 / 82 2 / 2
two links to each of 6 directories in a row 7 / 127 7 / 7
a link to the parent of the cwd 2 / 81 2 / 2
the root is a link, with a link to its parent inside 2 / 79 2 / 2
root = "tests" in bunfig.toml, tests/up -> .. 2 / 121 2 / 2
a/loop -> .. with --isolate or --parallel=2 82 / 82 2 / 2
shared -> ../outside (only route) 1 / 1 1 / 1
pkg -> .store/pkg (only route) 1 / 1 1 / 1
a link inside a linked directory 2 / 2 2 / 2

Filters and ignore patterns, tree proj/shared -> ../outside, cwd proj.

  • bun test shared/: 1.4.3 runs the test. This branch matches nothing and exits 1.
  • bun test outside/: 1.4.3 matches nothing. This branch runs the test.
  • bun test ./shared: both run the test.
  • --path-ignore-patterns 'shared/**' and 'shared': both prune the link.
  • --path-ignore-patterns 'shared/*.test.js': 1.4.3 ignores the file. This branch runs it.
  • --path-ignore-patterns '../outside/**': 1.4.3 runs the file. This branch ignores it.

The 8 new tests that pass on 1.4.3 too. They pin what must not change: a test that only a link leads to still runs, a filter on the real path still matches, and a link that is the scan root still works. A walk that opens each directory with O_NOFOLLOW cannot reach the directories of the first group.

Why 1.4.0 ended. Before #40016 (in 1.4.1, not in 1.4.0) the walk held one fd for each directory, so it ended when no file descriptor was left.

What I ran. On a debug build of this head: the 25 directory links tests and the 15 cases of #42921 pass, with path-ignore-patterns.test.ts (10 pass), test-shard.test.ts (29 pass), pass-with-no-tests.test.ts (5 pass), test-changed.test.ts (20 pass) and test/regression/issue/26851.test.ts (2 pass). The rest of bun-test.test.ts passes, except that two cases of "nested too deeply to print" from #44353 take 5.5 s on my debug build and hit the 5 s limit, with and without this diff. CI ran the file on every lane, Windows included, where the rows use junctions.

Known limits.

  • A directory between the root and a link target that can be searched but not read (chmod 111): the walk skips the link as covered, and cannot list that directory by itself.
  • Remove get_fd_path: derive paths from cwd and what was opened, not from fds #38365 removes get_fd_path and the real path from RealFS::kind. After it, the scanner's own lookup names every link target (open, fd path, close: 3 syscalls per directory link, 0 on a tree with no links), and real_path_of moves to bun_sys::realpath.
  • Other walks with the same defect, not changed here: bust_dir_cache_recursive (src/runtime/api/filesystem_router.rs) and FrameworkRouter::scan_inner (src/runtime/bake/FrameworkRouter.rs).

The reproductions come from #41465. That PR keeps only its other half, the report of a directory that cannot be read.

@robobun

robobun commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduction on Bun 1.4.3 (Linux x64):

mkdir t && cd t
echo 'import { test } from "bun:test"; test("s", () => {});' > s.test.js
ln -s . x && ln -s . y
bun test   # never exits; memory and open fds grow

With this branch the same tree prints Ran 1 test across 1 file. and exits 0.

This PR is a draft, stacked on #42921. One item is open: the question for a maintainer at the top of Notes. The cost numbers are in the body.

@robobun

robobun commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:58 AM PT - Oct 15th, 2026

⏳ @robobun, your commit 1ec4bee is still building in Build #126047, but has 2 failures so far (All Failures):

Comment thread src/resolver/fs.rs Outdated
Comment thread src/resolver/fs.rs Outdated
Comment thread src/resolver/fs.rs Outdated
Comment thread src/runtime/cli/test/Scanner.rs Outdated
Comment thread src/runtime/cli/test/Scanner.rs Outdated
Comment thread src/runtime/cli/test/Scanner.rs Outdated
Comment thread src/runtime/cli/test/Scanner.rs Outdated
Comment thread src/runtime/cli/test/Scanner.rs Outdated
Comment thread src/runtime/cli/test/Scanner.rs Outdated
Comment thread src/runtime/cli/test/Scanner.rs Outdated
Comment thread src/runtime/cli/test/Scanner.rs Outdated
@robobun
robobun force-pushed the robobun/d068ecf6/test-scanner-symlinks branch from 46db8f9 to a0ae1aa Compare October 15, 2026 13:23
@robobun
robobun changed the base branch from main to robobun/730a1a7f/test-scanner-cached-dirs October 15, 2026 13:23

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants